你问到的是真实项目里面 React 状态管理的核心问题。
很多 React 初学者知道:
props 变化 → re-render state 变化 → re-render context 变化 → re-render
但是实际业务:
这些场景里面:
到底是谁触发 re-render?数据怎么同步?
这才是工程实践。
下面我用几个真实项目模式说明。
这是最经典的 CRUD 场景。
例如:
xxxxxxxxxxUsers PageTable[TomJack]点击 Edit↓/users/1/edit修改 Tom -> Tom Lee↓Submit↓返回 Users PageTable[Tom LeeJack]
问题:
返回以后,React 怎么知道重新请求数据?
最简单:
xxxxxxxxxxUsersPagestate:users||Table
代码:
xxxxxxxxxxfunction UsersPage() { const [users, setUsers] = useState([]); async function loadUsers() { const res = await fetch("/api/users"); const data = await res.json(); setUsers(data); } useEffect(() => { loadUsers(); }, []); return ( <UserTable users={users} refresh={loadUsers} /> );}编辑:
xxxxxxxxxxfunction EditPage({ refresh }) { async function submit() { await updateUser(); refresh(); }}流程:
xxxxxxxxxxupdate database↓调用 refresh()↓fetch latest data↓setUsers()↓state changed↓UsersPage re-render↓Table 更新
这里真正触发 React 更新的是:
xxxxxxxxxxsetUsers(data)不是:
xxxxxxxxxx数据库变化React 根本不知道数据库变了。
React 不会监听你的数据库变化。数据库更新不会导致组件重新渲染,必须通过状态更新或者重新获取数据,让 React 感知变化。
大型项目一般不会手写:
xxxxxxxxxxuseEffect(fetch)而是 TanStack Query。
例如:
xxxxxxxxxxfunction UsersTable(){ const { data:users }=useQuery({ queryKey: ["users"], queryFn: getUsers }) return ( <Table data={users}/> )}编辑:
xxxxxxxxxxfunction EditUser(){ const queryClient = useQueryClient(); async function submit(){ await updateUser(); queryClient.invalidateQueries({ queryKey:["users"] }) }}流程:
xxxxxxxxxxEdit Form↓updateUser()↓invalidate ["users"]↓React Query 标记旧数据失效↓重新执行 getUsers()↓query data 更新↓useQuery 返回新 data↓组件 re-render
这里触发 React 更新的是 React Query 内部:
xxxxxxxxxxsetState / notify observer最终还是 state change
例如:
xxxxxxxxxxPageuseQuery(["users"])Edit ModalmutationinvalidateQueries(["users"])
两个组件 不需要 props 传来传去。
比如用户权限:
xxxxxxxxxx<AuthProvider> Dashboard</AuthProvider>Context:
xxxxxxxxxxconst [user,setUser]=useState(null);登录:
xxxxxxxxxxsetUser(newUser)所有:
xxxxxxxxxxuseContext(AuthContext)组件重新 render。
但是注意 Context 不适合表格数据。例如:
xxxxxxxxxx<UserContext> 10000 rows</UserContext>修改一个用户,所有消费者重新 render。性能不好。
例如Table:
xxxxxxxxxx?page=1&sort=name&search=Tom
Next.js:
xxxxxxxxxxconst searchParams = useSearchParams();当URL:
xxxxxxxxxx?page=2
变化,React发现 hook 返回值变化。所以组件重新 render。
例如:
xxxxxxxxxxrouter.push( "?page=2")流程:
xxxxxxxxxxURL change↓Next Router state update↓Component re-render↓useSearchParams new value↓fetch new data
如果是 App Router:
例如:
xxxxxxxxxx// app/users/page.tsxexport default async function Page(){ const users = await getUsers(); return <Table users={users}/>}现在编辑:
Server Action:
xxxxxxxxxx"use server"async function updateUser(){ await prisma.user.update() revalidatePath("/users")}然后:
xxxxxxxxxxupdate database↓revalidatePath("/users")↓Next.js invalidate route cache↓重新执行 Server Component↓生成新的 RSC payload↓Client merge↓Table 更新
这里不是 React state。
而是 Next.js 重新执行 Server Component。
例如删除:
点击:
xxxxxxxxxxDelete
不要等服务器。
立即:
xxxxxxxxxxTable remove row
React 19:
xxxxxxxxxxconst [optimisticUsers, removeUser] = useOptimistic(users);流程:
xxxxxxxxxxclick delete↓optimistic state update↓UI immediately update↓server mutation↓success↓real state sync
总结:
| 场景 | 触发方式 |
|---|---|
| 父组件管理数据 | setState,将setState方法传递给子组件调用 |
| React Query | invalidateQueries / setQueryData |
| Redux/Zustand | store update |
| Context | Provider value change |
| URL 状态 | router.push / searchParams |
| Next Server Component | revalidatePath |
| Cache 数据 | revalidateTag |
| Optimistic UI | useOptimistic |
| WebSocket | external store update |
如果面试官问:
How do you update a table after editing a record?
In React applications, updating the database itself does not trigger a re-render. The UI needs a state update or data revalidation mechanism. In small applications, I may lift the state up and call a refresh function after mutation. In production applications, I usually use React Query, where mutations invalidate related queries, causing the table data to refetch and update automatically. In Next.js App Router applications, Server Actions combined with revalidatePath or revalidateTag can trigger Server Components to regenerate and send updated RSC payloads.
React re-render 的本质不是:
xxxxxxxxxx数据库变化↓组件更新
而是:
xxxxxxxxxx数据源变化↓React 能感知的数据状态变化↓state/context/query/store/router↓render
数据库只是数据存储。
React 只响应状态,不响应数据本身。
这也是为什么现代 React 项目会引入:
因为它们本质都是在解决:
"数据变化以后,如何让 React 知道并正确更新 UI。"
我从来没有实现这个功能,在vue里面也没有,而且vue里面的菜单组件搞得很复杂,而且路由系统也非常复杂,所以我以为react里面也应该会很复杂。
但其实react里面很简单,只需要从后端获取到菜单数据,然后渲染出来就行了,就这么简单。当然如果有固定的菜单项,可以写在前端代码里面,拿到数据之后拼接即可。
菜单页面更简单了,就是普通的table+CRUD页面而已。那菜单页面更新了之后,怎么通知菜单栏重新获取数据更新呢?这个也很简单,使用react query的方法即可。
权限怎么做呢?不同的角色、不同的权限会显示不同的菜单栏。
我之所以觉得很难,是因为我没有搞懂整个流程,我以为前端要处理一切,至少看vue里面的代码让我有这种感觉,vue里面确实太复杂了。
其实前端只需要获取数据、渲染菜单、保护路由而已。而后端数据才是唯一真相源。
在实际企业级系统(CRM / ERP / SaaS / Admin Dashboard)中,除了 Permission-based Navigation(基于权限的导航系统),还有很多典型的工程难点。
下面只列项目级难点:
CRM 非常常见:
CRM 高频:
例如:
客户信息:
例如:
编辑用户以后列表更新:
相关:
CRM 常见:
技术:
CRM 常见:
难点:
例如:
客户搜索:
技术:
企业系统非常重要:
记录:
例如:
xxxxxxxxxxTom changed customer status:Before:LeadAfter:Active
CRM 高频:
例如:
销售流程:
xxxxxxxxxxLead↓Contacted↓Proposal↓Closed
难点:
包括:
例如:
导入 100 万客户。
不能:
xxxxxxxxxxawait import()直接阻塞。
需要:
技术:
难点:
尤其:
Cache invalidation is one of the hardest problems in software engineering.
大型 CRM:
国际 SaaS 常见:
包括:
前后端:
技术:
CRM 核心:
例如:
销售 Dashboard:
企业客户常要求:
包括:
例如:
移动 CRM:
大型系统:
如果按照 Senior React / Next.js Full-stack 面试优先级 排序,我认为最高频的是:
xxxxxxxxxx1. Permission-based Navigation / RBAC2. Large Data Table3. Complex Form4. Data Synchronization5. Authentication & Authorization6. Caching Strategy7. Performance Optimization8. File Upload9. Real-time Update10. Audit Log11. Multi-tenant Architecture12. Workflow Engine
这些基本就是企业级 CRM / SaaS 项目里最容易体现 Senior Engineer 能力的地方。
首先我会告诉面试官:
In enterprise applications, I usually implement Role-Based Access Control (RBAC). The backend is the source of truth for permissions, while the frontend is responsible for rendering menus and protecting routes based on the permission data returned by the server.
整个流程如下:
xxxxxxxxxx ┌────────────────────┐ │ Login │ └─────────┬──────────┘ │ ▼ Verify username/password │ ▼ Create Session / JWT │ ▼ Query User + Role + Permissions │ ▼ ┌────────────────────────────────┐ │ Build Permission Model │ │ │ │ User │ │ └── Role │ │ └── Permissions │ │ └── Menus │ └────────────────────────────────┘ │ ▼ Return User + Menu Tree + Permissions │ ▼ Next.js Layout / React App │ ┌────────────────┴───────────────┐ ▼ ▼ Render Sidebar Store Permissions │ │ ▼ ▼ Dynamic Navigation Route / Button Guard整个系统可以分成 四层:
下面逐层解释。
用户输入:
xxxxxxxxxxusernamepassword流程:
xxxxxxxxxxUser │ ▼POST /login │ ▼Backend │ ▼Verify Password │ ▼Create Session / JWT │ ▼Return Cookie例如:
xxxxxxxxxxTom↓Session↓session.userId = 1001这里还没有权限。
只是知道:当前登录的是谁。
登录以后:
后端根据:
xxxxxxxxxxuserId查询:
xxxxxxxxxxusers↓roles↓permissions↓menus例如数据库:
xxxxxxxxxxusersTom↓role_id = 2roles:
xxxxxxxxxxAdminSalesSupportpermissions:
xxxxxxxxxxcustomer:viewcustomer:createcustomer:deleteorder:viewmenus:
xxxxxxxxxxDashboardCustomersOrdersSettings关系:
xxxxxxxxxxTom │ ▼Sales Role │ ▼customer:viewcustomer:createorder:view │ ▼DashboardCustomersOrders最终后端得到:
xxxxxxxxxx{ "permissions": [ "customer:view", "customer:create", "order:view" ], "menus": [ { "name": "Dashboard" }, { "name": "Customers" }, { "name": "Orders" } ]}这里有一个非常重要的原则:
后端才是权限的唯一可信来源(Source of Truth)。
前端不要自己计算权限。
前端收到:
xxxxxxxxxx[ Dashboard, Customers, Orders]Layout:
xxxxxxxxxxDashboard Layout │ ▼ Fetch Menu Tree │ ▼ Render SidebarSidebar:
xxxxxxxxxxDashboardCustomersOrders管理员:
xxxxxxxxxxDashboardCustomersOrdersSettings销售:
xxxxxxxxxxDashboardCustomers所以菜单完全来自数据库。
而不是:
xxxxxxxxxx<Menu> Dashboard Customers Orders</Menu>写死。也不是前端自己计算。
菜单隐藏不代表页面安全。
例如用户直接输入:
xxxxxxxxxx/settings怎么办?
流程:
xxxxxxxxxxUser↓/settings↓Middleware / Server↓Permission Check↓Has Permission ? / \ / \ Yes No │ │ ▼ ▼ Render 403因此页面也必须验证:
xxxxxxxxxxsetting:view而不是只依赖Sidebar。
很多 CRM 不仅页面有权限。按钮也有权限。
例如 Customer 页面:
xxxxxxxxxx+----------------------+Customers[Create]------------------------TomJackMike Edit Delete+----------------------+销售:
xxxxxxxxxxCustomers[Create]TomJackMikeEdit管理员:
xxxxxxxxxxCustomers[Create]TomJackMikeEdit Delete客服:
xxxxxxxxxxCustomersTomJackMike按钮也是Permission。
例如:
xxxxxxxxxxcustomer:createcustomer:updatecustomer:delete前端根据 permission 决定 Render 哪个 Button。
route protection 和 button permission是怎么实现呢?前端要在页面里面写死setting:view这样的权限数据吗?然后Button里面也要写死 setting:view 这样的权限数据吗?
是的,前端通常需要引用 permission key,但是不会把权限逻辑写死在每个页面里,需要封装一些常量、方法之后再使用。
正确设计是:
- 后端负责定义和校验权限(source of truth)
- 前端只消费 permission key,用统一的封装控制 UI 和路由
按钮
不要这样写:
xxxxxxxxxxfunction SettingsPage(){return (<>{permissions.includes("setting:view")&&<Button>Edit</Button>}</>)}虽然能工作,但是项目大了会崩。
正确封装:
xxxxxxxxxxfunction usePermission(){const permissions = useContext(AuthContext);function can(permission:string){return permissions.includes(permission);}return {can}}使用:
xxxxxxxxxxfunction SettingsPage(){const {can}=usePermission();return (<>{can("setting:update")&&<Button>Save</Button>}</>)}这样最起码不会在每个页面里面都写获取permission的代码,简单多了。
大型项目经常这样,这个就更简单了,组件使用起来是最清晰的:
xxxxxxxxxx<Can permission="setting:update"><Button>Save</Button></Can>实现:
xxxxxxxxxxfunction Can({permission,children}){const {can} = usePermission();if(!can(permission)){return null;}return children;}
页面
client components
例如:
xxxxxxxxxx<Routepath="/settings"element={<ProtectedRoutepermission="setting:view"><Settings /></ProtectedRoute>}/>ProtectedRoute 组件的代码:
xxxxxxxxxxfunction ProtectedRoute({permission,children}){const {can}=usePermission();if(!can(permission)){return <Navigate to="/403"/>}return children;}流程:
xxxxxxxxxx访问 /settings|▼ProtectedRoute|▼检查 setting:view|----------| |true falses| |▼ ▼页面 403server components
xxxxxxxxxxexport default async function Page(){const session = await getSession();const hasPermission = await checkPermission(session.user.id,"setting:view");if(!hasPermission){redirect("/403");}return (<Settings />)}流程:
xxxxxxxxxxRequest↓Server Component↓check permission↓Render / Redirect或者可以直接在proxy.js里面进行检查。
编写常量
一般都会编写常量到 permissions.ts 里面,方便管理、文件也可以拆分、而且有代码提示。
xxxxxxxxxxexport const PERMISSIONS = {USER_VIEW:"user:view",USER_CREATE:"user:create",SETTING_VIEW:"setting:view",SETTING_UPDATE:"setting:update"} as const;然后Button:
xxxxxxxxxx<Can permission={PERMISSIONS.SETTING_UPDATE}><Button>Save</Button></Can>
这是最重要的一层。
很多新人认为按钮隐藏就安全。其实不是。
例如攻击者直接:
xxxxxxxxxxDELETE/api/customer/1如果后端不检查:
xxxxxxxxxxcustomer:delete数据库一样删掉。
所以真正流程:
xxxxxxxxxxBrowser↓DELETE /customer/1↓Backend↓Permission Check↓Has customer:delete ? / \ / \ Yes No │ │ ▼ ▼ Delete 403前端权限只负责体验,后端权限才负责安全。
xxxxxxxxxx User Login │ ▼ Authentication (Who are you?) │ ▼ Query Role & Permission Model │ ▼ Backend Builds Menu + Permission List │ ▼ Return Session + Menus + Permissions │ ┌──────────┴──────────┐ ▼ ▼ Render Sidebar Store Permissions │ │ ▼ ▼ Dynamic Navigation Route Guard │ ▼ Button Permission │ ▼ API Permission │ ▼ Database
很多候选人会说:
"The frontend controls permissions."
这句话不准确。
更准确的说法是:
The frontend is responsible for rendering the UI based on permissions, while the backend is responsible for enforcing permissions. The backend is always the source of truth for authorization.
这句话在 Senior 面试里是一个比较重要的观点,因为它体现了你对安全边界(Security Boundary)的理解:前端负责展示(Presentation),后端负责授权(Authorization Enforcement)。
Large Data Table(大型数据表格) 是 Senior React / Next.js 面试非常高频的问题,尤其是在 CRM、ERP、后台管理系统中。
面试官通常不是想听:
“我用 TanStack Table。”
而是想知道:
下面按照 Senior Full-stack 面试回答方式 来讲。
假设 CRM 有:
整体流程:
xxxxxxxxxxUser||Change table state|┌─────────────────┼─────────────────┐▼ ▼ ▼Pagination Search Sorting| | |└─────────────────┼─────────────────┘|▼Build Query Params|▼React Query / Server Action|▼API Request|▼Backend Processing|┌─────────────────┼─────────────────┐▼ ▼ ▼Database Permission Validation|▼Indexed Query|▼Return Page Data|▼Update Table State|▼Render
很多新人方案:
xxxxxxxxxxconst users = await fetch("/api/users");然后:
xxxxxxxxxxusers.map()假设 100 万条:
xxxxxxxxxxDatabase1000000 rows↓API↓Browser↓React render
问题:
传输巨大 JSON:
xxxxxxxxxx100MB+
浏览器保存:
xxxxxxxxxx1000000 objects
React:
xxxxxxxxxx1000000 DOM nodes
直接崩。
所以大型系统第一原则:
Never send all data to the client.
这是 CRM 最常见方案。
例如第一页:
xxxxxxxxxxGET /customers?page=1&limit=20
流程:
xxxxxxxxxxUser clicks page 2|▼page state update|▼React Query refetch|▼GET /customers?page=2|▼DatabaseLIMIT 20 OFFSET 20|▼Return:{data:[customer1,customer2],total:1000000}|▼Table render
数据库:
xxxxxxxxxxSELECT *FROM customersLIMIT 20OFFSET 40;
小数据例如 500 条可以:
xxxxxxxxxxFetch all↓Browser pagination
但是大数据:
xxxxxxxxxx1000000 rows↓Server pagination
面试回答:
For large datasets, I prefer server-side pagination because fetching all records to the client increases network cost, memory usage, and rendering overhead.
例如输入:
xxxxxxxxxxTom
不要:
xxxxxxxxxxusers.filter()因为数据不在客户端。
流程:
xxxxxxxxxxUser types "Tom"|▼Debounce 300ms|▼Update search param|▼GET /customers?search=Tom|▼Database query|▼Return matched rows
Frontend:
xxxxxxxxxxconst debouncedSearch =useDebounce(search,300);请求:
xxxxxxxxxx/customers?search=Tom&page=1
Backend:
xxxxxxxxxxSELECT *FROM customersWHERE name LIKE '%Tom%'LIMIT 20;
例如点击:
xxxxxxxxxxName ↑
不要:
xxxxxxxxxxarray.sort()因为数据不完整。
流程:
xxxxxxxxxxClick column|▼sort state update|▼API request/customers?sort=name&order=asc|▼DatabaseORDER BY name ASC|▼Return data
数据库:
xxxxxxxxxxSELECT *FROM customersORDER BY created_at DESCLIMIT 20;
例如 CRM:
xxxxxxxxxxStatus:[Active]Country:[Japan]
流程:
xxxxxxxxxxFilter Change↓Update filters state↓Build query↓API↓Database WHERE↓Return result
例如 URL:
xxxxxxxxxx/customers?status=active&country=japan
大型项目不要:
xxxxxxxxxxconst [page,setPage]const [sort,setSort]const [filter,setFilter]散落。
通常统一:
xxxxxxxxxxtype TableState = { page:number; pageSize:number; sorting:{ field:string; direction:"asc"|"desc"; } filters:{ status:string; } search:string;}例如:
xxxxxxxxxxconst [tableState,setTableState]=useState<TableState>({ page:1, pageSize:20, sorting:null, filters:{}});更成熟:
放 URL:
xxxxxxxxxx/customers?page=2&sort=name&status=active
好处:
Next.js:
xxxxxxxxxxuseSearchParams()
企业项目通常不用:
xxxxxxxxxxuseEffect(fetch)
而是:
xxxxxxxxxxTable UI↓useQuery↓API↓Cache
例如:
xxxxxxxxxxconst { data, isLoading}=useQuery({ queryKey:[ "customers", tableState ], queryFn: ()=>fetchCustomers(tableState)});更新:
xxxxxxxxxxpage change↓queryKey change↓React Query refetch↓Table update
假设 100 rows。
普通:
xxxxxxxxxxParent render↓100 Row render
优化:
xxxxxxxxxxconst Row =memo(CustomerRow);避免:
xxxxxxxxxx<Table columns={[ {} ]}/>每次创建新引用。
使用:
xxxxxxxxxxconst columns =useMemo(()=>[],[]);
如果不是分页。
例如 Excel 类应用 10000 rows。
不要:
xxxxxxxxxxDOM10000 rows
而是:
xxxxxxxxxxViewport||Row 1Row 2Row 3(scroll)Row 100Row 101
只渲染可见区域。
技术:
流程:
xxxxxxxxxx100000 rows|▼Virtualizer|▼Only render visible rows|▼Small DOM
错误:
xxxxxxxxxxClick Export↓Browser generate CSV
100 万数据,浏览器会卡死。
正确:
xxxxxxxxxxUser click Export|▼Create Export Job|▼Background Worker|▼Generate CSV|▼Store File(S3)|▼Notification|▼Download
最终:
xxxxxxxxxxBrowser||Table Component|┌───────────┴───────────┐▼ ▼Table State React Query(page/filter/sort) || |└───────────┬───────────┘|▼API Layer|▼Authorization|▼Service Layer|▼Database|▼Indexed Query Result
如果面试官问:
How do you design a large data table for millions of records?
可以这样回答:
For large datasets, I avoid loading all records into the browser. I usually implement server-side pagination, filtering, and sorting. The table state is managed centrally and often synchronized with URL parameters so users can share and restore the same view. On the frontend, I use React Query to manage server state and caching, and I optimize rendering with memoization or virtualization when necessary. On the backend side, I make sure database queries are optimized with proper indexes and permission checks. For extremely large exports, I use background jobs instead of blocking the request.